Skip to content

[#950] Announce a ReplicaOfflineMsg before it is published, not after it may have been forwarded - #978

Merged
vharseko merged 1 commit into
OpenIdentityPlatform:masterfrom
vharseko:issues/950-announce-replica-offline-before-publish
Sep 15, 2026
Merged

vharseko merged 1 commit into
OpenIdentityPlatform:masterfrom
vharseko:issues/950-announce-replica-offline-before-publish

Conversation

@vharseko

@vharseko vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes #950

The bug

LDAPReplicationDomain.publishReplicaOfflineMsg() recorded the announcement after
pendingChanges.putReplicaOfflineMsg() returned, and that call has already put the message on
the wire: pushCommittedChanges() reaches domain.publish(msg) -> ReplicationBroker.publish()
-> session.publish(msg) before it comes back.

A collocated replication server which forwards the message in that window calls
DSRSShutdownSync.replicaOfflineMsgForwarded() from its ServerWriter, which finds no entry for
the replica and does nothing but notify the monitor. replicaOfflineMsgSent() then installs a
PendingOfflineMsg which nothing will ever remove - the forward it was waiting for has already
happened.

Since #919 that record is the condition of a blocking wait: ReplicationServer.shutdown() calls
awaitReplicaOfflineMsgsForwarded() and, with a peer RS connected, spends the whole
REPLICA_OFFLINE_GRACE_PERIOD on a message which is on the wire and forwarded. Nothing is lost -
the topology has the announcement - it is a bounded delay of the shutdown. Before #919 the stale
record was harmless, and the ordering it depends on has been there since OPENDJ-1453.

#946 has since narrowed which messages are announced - only those which really were published -
but left the ordering alone: the announcement of a published message still follows its publish.

The window is narrow: between the return of session.publish() and the next statement of the
publishing thread, the collocated RS has to read the socket, write the changelog, queue the
message on the peer handler and write it to the peer session. But the cost of losing the race is
precisely the delay the grace period exists to bound.

The change

The announcement moves to the point where the message is published - the ReplicaOfflineMsg
branch of PendingChanges.pushCommittedChanges() - through a ReplicaOfflineAnnouncer the
domain hands to its PendingChanges. It is therefore in place before session.publish() is
reached, and the ConcurrentHashMap it is written to gives the forwarding thread, which reads it
only after reading the socket, the visibility it needs. The forward can no longer precede it.

The announcer the domain hands over is ShutdownSyncAnnouncer, a package-private class of one
domain and one DSRSShutdownSync: announce() is replicaOfflineMsgSent(), withdraw() is
replicaOfflineMsgNotSent(). It is a class rather than an anonymous one so that
PendingChangesTest builds its pending changes with the very announcer the domain uses, and a
swap of the two calls dies there.

Announcing at the publish site, rather than before the whole putReplicaOfflineMsg(), also means
the announcement follows the publication instead of the queueing. A message which a change in
flight holds back (#918) is not announced at all: #946 gives up on such a message rather than
letting it out late, so there is no later publish to announce it at.

That leaves the if (offlineCSN != null) guard #946 put around the announcement nothing to do,
which is what its own description predicted: a message which is not published is not announced.
publishReplicaOfflineMsg() keeps only the trace #946 added, with the wording #976 gave it.

One announcement does have to be withdrawn. Since #976 domain.publish() reports whether the
broker wrote the message, and it refuses one when it has no usable session, when a recovery is
pending, or when it is stopped in between - all after the announcement was made. Such an
announcement is one nobody will ever forward, so pushCommittedChanges() takes it back through
the announcer, and DSRSShutdownSync.replicaOfflineMsgNotSent() withdraws only the entry
carrying that CSN, and wakes the shutdown up as a forward does. This is the shape #950 proposed,
and what "not fixed here: #949" of the earlier revision of this description was waiting for.

What stays announced is what the broker reports as written - not more than that:
Session.publish() returns without writing for a peer which cannot decode the message and once
the session's close is initiated, and the broker reports both as published. That is #976's
contract, unchanged here.

A withdrawal must not take an earlier announcement with it. A replica announces itself offline
on every disableService(), and replicaOfflineMsgSent() replaces the entry of the replica.
Every road which disables the service and enables it again - a change of the fractional or
assured configuration, restartService(), the disable()/enable() pair of a total update,
the restartSession() of #974 after a failed replay - can therefore, within the grace period of
a message which went out, run an enableService() whose connect fails or raises
connectRequiresRecovery, and then a disableService() which announces a second message the
broker refuses; withdrawing that one used to empty the slot the first one was still waiting in.
PendingOfflineMsg now keeps the announcement it displaced, and the withdrawal puts it back: the
earlier message, which did go out, keeps its wait - with its own clock, and with the peers it was
queued for.

Two bounds on what is kept. The displaced announcement is kept only while its own grace period
runs: past it, it holds nothing back any more, and keeping it would chain every announcement of
a replica whose message nobody in this process forwards - a directory server without a
collocated replication server, or connected to a remote one, where restartSession() announces
again every few seconds for as long as a replay keeps failing - for the life of the process. And
the withdrawal gives back only an announcement which is still owed a forward: the forward which
released the displaced one may have been reported while the new announcement was being made, so
that the forward's identity remove() found the new entry in its place; restored, such an
announcement would hold the shutdown for the rest of its grace period, for a forward nobody will
report again. Neither bound has a case: the first changes retention and nothing observable, the
second needs the announcement to land between the two statements of
replicaOfflineMsgForwarded(), which no test can arrange without a hook.

What stays not seen. Whatever is reported about the earlier message while the refused one stands
in its place is lost to it: a forward, or a peer going away, after which the shutdown waits out
what is left of the earlier message's own grace period; the recording of its peers, after which
the first forward ends its wait, as for a message no peer was recorded for. That window is the
one refused publish - at once on connectionError or connectRequiresRecovery, the broker's
retry loop up to the reconnect when it has no session - and the wait it can cost is bounded by a
grace period which is already running. aForwardReportedWhileARefusedAnnouncementStoodIsNotSeen
pins the trade-off, so that a change to it is made knowingly.

A trade-off worth naming

The grace period is now counted from just before the publish instead of just after it. Normally
that is microseconds. With the send window closed the broker loops on tryAcquire(500 ms), and a
slow publish eats part of the 5 seconds before the message even leaves. The direction is the safe
one - the wait can only end earlier, never later - and newShutdownDeadline() bounds the whole
shutdown independently.

Tests

PendingChangesTest drives a real DSRSShutdownSync through the production ShutdownSyncAnnouncer.
The five cases #946 and #976 left there are kept as they were, and four are new:

  • theReplicaOfflineMsgIsAnnouncedBeforeItIsPublished reports the forward from inside
    publish(), which is the moment the message reaches the session, so the race is reproduced
    rather than waited for. It asserts from there that the announcement is already in place, and
    afterwards that the forward cleared it.
  • theAnnouncementOfAPublishedMessageStandsUntilItIsForwarded pins that an announcement the
    broker took is not withdrawn: with nobody having forwarded it, the shutdown must wait.
  • theAnnouncementOfAReplicaOfflineMsgTheBrokerRefusedIsWithdrawn checks from inside
    publish() that the announcement is already in place, refuses the message the way a broker
    with no session does, and asserts nothing holds the shutdown back afterwards - so it pins a
    withdrawal, not an announcement which was never made.
  • theReplicaOfflineMsgHeldBackByAChangeInFlightIsNeverAnnounced pins the other half: nothing is
    announced while a change in flight holds the message back, and nothing is announced when that
    change completes either - [#918] Record a ReplicaOfflineMsg as sent only when it really was published #946 gives up on such a message rather than letting it out late.

DSRSShutdownSyncTest grows seven cases for the withdrawal: it ends the wait and leaves nothing
behind; it wakes a waiting shutdown up; the withdrawal of an earlier announcement leaves a newer
one of the same replica alone, and is not taken for a restore either - a forward of the
withdrawn message is ignored afterwards; the withdrawal of a later announcement gives the earlier
one its wait back, which a forward of the earlier message then ends; the restored announcement
is still owed the forwards its message was queued for, so the first of them does not end the
wait; it keeps what is left of its own grace period rather than starting a new one; and a
forward reported while the refused announcement stood is not seen.

Five mutants were run against the suite, each dying where its name says:

  • the announcement moved back behind domain.publish() -
    theReplicaOfflineMsgIsAnnouncedBeforeItIsPublished and
    theAnnouncementOfAReplicaOfflineMsgTheBrokerRefusedIsWithdrawn, both on "the message must be
    announced before it is published"
    ;
  • the withdrawal run on both arms of if (domain.publish(msg)) -
    theAnnouncementOfAPublishedMessageStandsUntilItIsForwarded;
  • replicaOfflineMsgSent and replicaOfflineMsgNotSent swapped in ShutdownSyncAnnouncer -
    three cases of PendingChangesTest;
  • the displaced announcement rebuilt instead of restored -
    new PendingOfflineMsg(displaced.csn, System.nanoTime(), null) in replicaOfflineMsgNotSent -
    theRestoredAnnouncementIsStillOwedTheForwardsItWasQueuedFor and
    theRestoredAnnouncementKeepsWhatIsLeftOfItsOwnGracePeriod; with the peers copied over and
    only the clock reset, the second one alone;
  • the csn.equals guard of the withdrawal dropped -
    theWithdrawalOfAnEarlierMessageLeavesANewerOneAlone, on "the stale withdrawal was ignored,
    not turned into a restore"
    .

Two more survive by design, and are listed so that nobody looks for the case which kills them:
the withdrawal restoring the displaced announcement whether or not it is still owed a forward,
and the displaced announcement kept past its grace period - the bounds named above.

theWithdrawalOfALaterMessageGivesTheEarlierOneItsWaitBack was watched failing before the
displaced announcement was kept: "the earlier message went out and nobody has forwarded it yet -
expected false but was true"
.

Overlaps

@vharseko vharseko added bug replication concurrency Thread-safety / race-condition bugs java tests Test suites: fixing, enabling, un-disabling labels Sep 9, 2026
@vharseko
vharseko requested a review from maximthomas September 9, 2026 05:47
@vharseko vharseko removed the java label Sep 9, 2026
@vharseko
vharseko force-pushed the issues/950-announce-replica-offline-before-publish branch from 8175d6e to 848c47c Compare September 9, 2026 10:03
@vharseko

vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Rebased onto master now that #946 has landed. The conflict both PRs predicted is resolved and the description above is updated to match; no review had been posted yet, so nothing here answers a review comment.

The two changes met in the same three files.

LDAPReplicationDomain.publishReplicaOfflineMsg()#946 wrapped the announcement in if (offlineCSN != null). With the announcement moved to the publish site there is nothing left to guard, so the method keeps only the trace #946 added for the message a change in flight held back:

final CSN offlineCSN = pendingChanges.putReplicaOfflineMsg();
if (offlineCSN == null && logger.isTraceEnabled())
{
  /*
   * The announcement itself is made where the message is published, so nothing has to be
   * reported here: a message which never reached the wire was never announced either.
   */
  logger.trace("Replica " + getServerId() + " of domain baseDN=" + getBaseDN()
      + " could not announce itself offline: a change which is still in flight holds"
      + " the message back, and " + pendingChanges.size() + " change(s) are pending");
}

PendingChanges.java — merged without a conflict, and both halves stand: putReplicaOfflineMsg() still gives up on the message which stayed queued and returns null, while pushCommittedChanges() announces before domain.publish(msg).

PendingChangesTest.java — an add/add conflict, now the union of both files, five cases. The three #946 added are byte-for-byte unchanged; only the newPendingChanges() helper grew the announcer, behind an overload which keeps those three calling it with one argument.

One case needed more than a merge, and it is worth naming. theReplicaOfflineMsgHeldBackByAChangeInFlightIsAnnouncedOnlyWhenItIsPublished asserted that a held-back message is announced once the change in flight lets it out. After #946 there is no such message left to let out - putReplicaOfflineMsg() removes it from the queue rather than leaving it there - so the assertion contradicted master. It is now theReplicaOfflineMsgHeldBackByAChangeInFlightIsNeverAnnounced, and pins both points: nothing announced while the change is in flight, nothing announced when it completes either.

PendingChangesTest, DSRSShutdownSyncTest and ReplicationServerShutdownSyncTest: 25 tests, all green. The regression the first new case exists for is still caught - putting announce() back behind domain.publish() fails it on "the message was forwarded, so nothing must hold the shutdown back any longer".

@vharseko

vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

For the record, since the run on the pre-rebase head 8175d6e went red:
DependencyTest.addModDelDependencyTest failed there, and it does not look like this change.

The map this branch moves the write of - DSRSShutdownSync.replicaOfflineMsgs - is read only
by ReplicationServer.shutdown() and by the non-DS branch of ServerWriter. That test has a
single replication server and only DS handlers, and its replication server is shut down in the
finally after the assertion, so neither reader runs before it. Ten local runs of
DependencyTest, five with this patch and five without, were green at 5.36-5.48 s against the
30 s budget the failure exhausted.

It looks like #924; the evidence, and a second sighting of the same signature on another
branch, are in
#924 (comment).

@vharseko
vharseko force-pushed the issues/950-announce-replica-offline-before-publish branch from 848c47c to ec70866 Compare September 10, 2026 07:24
@vharseko

Copy link
Copy Markdown
Member Author

Rebased onto master (36d4af9bd7) to pick up the fix for #924. Same single commit, now ec70866ee6,
and git log origin/master..HEAD shows only it. No code of this branch moved: the diff against
master is the same 128 added and 15 removed lines in the same three files.

Only LDAPReplicationDomain.java was touched by both sides, and in different places - #971 rewrote
enable(), this branch moves the announcement out of publishReplicaOfflineMsg() and into
PendingChanges.pushCommittedChanges(). Nothing to reconcile beyond the merge.

This finishes the note above about the red run on the pre-rebase head. That failure now has a name:
it is #924, and the fix for it reached master on 2026-09-09 13:44 UTC, after this branch was cut from
2a7bb9d7ed. What settles it is the logs/access of that job - note that it is
attempt 1 of run
34316279431 (job 102353062700) which holds it, since the job id now serves the cancelled re-run. The
last operation logged there is the MODIFY dn="o=test" which saves the ServerState right after
enable(), at 06:45:06, and then nothing at all for the 30 s the test waits. A delivery which arrives
in that window is given up on and never asked for again, which is exactly what #971 fixes. The ten
local runs reported above were green because the window is narrow, not because the test is unaffected.

Verified on the rebased branch rather than on the old head:

  • opendj-server-legacy test-compiles.
  • PendingChangesTest 5, DSRSShutdownSyncTest 12, ReplicationServerShutdownSyncTest 8 - 25
    tests, no failures.

@vharseko
vharseko force-pushed the issues/950-announce-replica-offline-before-publish branch from ec70866 to 4aeb3b3 Compare September 11, 2026 19:10
@vharseko

Copy link
Copy Markdown
Member Author

Rebased onto master (13d57e063c), where #976 and #947 have landed. Still one commit, now
4aeb3b35d2; git log origin/master..HEAD shows only it. No review had been posted, so nothing
here answers one.

The conflict was with #976, in the ReplicaOfflineMsg branch of pushCommittedChanges(): master
now reads the answer of domain.publish(msg), this branch announces before that call. Taking both
is not enough - an announcement made before a publish the broker then refuses is exactly the stale
record #976 removed, back in a new shape. So the resolution is the one the description of #950
proposed, and what this PR had listed under "not fixed here":

final CSN offlineCSN = msg.getCSN();
replicaOfflineAnnouncer.announce(offlineCSN);
if (domain.publish(msg))
{
  publishedOfflineCSN = offlineCSN;
}
else
{
  // The broker wrote it to no session, so nobody will forward what was announced.
  replicaOfflineAnnouncer.withdraw(offlineCSN);
}

ReplicaOfflineAnnouncer grew withdraw(), and DSRSShutdownSync a matching
replicaOfflineMsgNotSent(): it removes only the entry carrying that CSN - the two-argument
remove() the forward guard already uses - and wakes the shutdown up the way a forward does.
putReplicaOfflineMsg(), its verdict and the trace of publishReplicaOfflineMsg() are as #976 left
them; only the comment above the trace now names the withdrawal.

#947 changed replicaOfflineMsgForwarded() to take the peer id, so the forward the test reports
from inside publish() names one; with no peer recorded the first forward still ends the wait,
which is the fallback #947 kept for exactly this shape. The other case that fallback's comment
named - an announcement recorded after its message was relayed - no longer exists, and the comment
no longer says it does.

Tests, on the rebased head:

  • PendingChangesTest is the union of both sides plus one case, 8 in all. New here:
    theAnnouncementOfAReplicaOfflineMsgTheBrokerRefusedIsWithdrawn checks from inside publish()
    that the announcement is in place, refuses the message, and asserts nothing holds the shutdown
    back afterwards.
  • DSRSShutdownSyncTest grows three cases for the withdrawal: it ends the wait, it wakes a waiting
    shutdown up, and the withdrawal of an earlier announcement leaves a newer one of the same replica
    alone.
  • PendingChangesTest 8, DSRSShutdownSyncTest 25, ReplicationServerShutdownSyncTest 13 - 46
    tests, no failures, -Pprecommit checkstyle included.

Both regressions were watched: with the announcement moved back behind domain.publish(),
theReplicaOfflineMsgIsAnnouncedBeforeItIsPublished fails on "the message was forwarded, so
nothing must hold the shutdown back any longer"
; with the withdraw() call removed,
theAnnouncementOfAReplicaOfflineMsgTheBrokerRefusedIsWithdrawn fails on "the message never
reached the wire, so nothing must hold the shutdown back"
.

The description above is updated to match.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The order is now the right one, and a refusal is no longer silent.

  • Announcing before domain.publish() closes the #950 window by construction: a forward reported from inside the publish can no longer run ahead of the announcement it clears.
  • PendingChanges.ReplicaOfflineAnnouncer keeps PendingChanges off DSRSShutdownSync; the seam is two methods, and the tests build their own announcer through it.
  • replicaOfflineMsgNotSent withdraws with remove(key, value) under a csn.equals guard, so a stale withdrawal cannot take a fresher entry with it.
  • refuseWhilePublishing asserts from inside the publish Answer — the race is reproduced, not waited for.

Blocking

opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:115-117, :142-144opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PendingChanges.java:214-224

issue (blocking): A withdrawal empties a slot that announce() has already re-used, so the sent predecessor loses its wait.

replicaOfflineMsgSent is a put(): it replaces the replica's pending entry. On one domain, within the grace period:

  1. disableService()broker.stop() → CSN1 announced and published; the collocated RS still has it queued to the peer RS behind a backlog (the case the forward guard's comment at :205-210 names).
  2. enableService()broker.start() — the connect fails silently (connectionError, ReplicationBroker.java:850-853) or connectRequiresRecovery is raised (LDAPReplicationDomain.java:5356-5363).
  3. Second broker.stop()announce(CSN2) replaces CSN1's entry → publish() returns false → withdraw(CSN2) finds pending.csn.equals(CSN2) and removes the slot.

awaitReplicaOfflineMsgsForwarded() now waits for nothing and CSN1 is never forwarded before the RS goes down — #919's guarantee is gone for the RS downtime. At BASE a refused CSN2 was never announced, so CSN1's wait survived. Producers: restartService() (back-to-back, from readAssuredConfig / readFractionalConfig), the total-update disable() / enable(), followed by shutdown() or another config change.

Suggested shape — a withdrawal puts back what the announcement displaced:

// PendingOfflineMsg
/** The announcement this one displaced and which is still owed its forward; null when there was none. */
private final PendingOfflineMsg displaced;

// replicaOfflineMsgSent
replicaOfflineMsgs
    .computeIfAbsent(baseDN, dn -> new ConcurrentHashMap<>())
    .compute(offlineCSN.getServerId(),
        (id, displaced) -> new PendingOfflineMsg(offlineCSN, System.nanoTime(), displaced));

// replicaOfflineMsgNotSent
if (pending != null && pending.csn.equals(offlineCSN))
{
  if (pending.displaced != null)
  {
    msgs.replace(serverId, pending, pending.displaced);
  }
  else
  {
    msgs.remove(serverId, pending);
  }
}

And the case for the reachable order (DSRSShutdownSyncTest):

/** The shutdown's message went out; the re-enable's was refused: the first one is still owed its forward. */
@Test
public void theWithdrawalOfALaterMessageGivesTheEarlierOneItsWaitBack() throws Exception
{
  final DSRSShutdownSync shutdownSync = new DSRSShutdownSync(LONG_GRACE_PERIOD);
  final CSN sentByTheShutdown = newCSN(SERVER_ID, 1);
  final CSN refusedByTheBroker = newCSN(SERVER_ID, 2);

  shutdownSync.replicaOfflineMsgSent(baseDN1, sentByTheShutdown);
  shutdownSync.replicaOfflineMsgSent(baseDN1, refusedByTheBroker);
  shutdownSync.replicaOfflineMsgNotSent(baseDN1, refusedByTheBroker);

  assertThat(shutdownSync.canShutdown(baseDN1)).isFalse();
  shutdownSync.replicaOfflineMsgForwarded(baseDN1, sentByTheShutdown, RS_ID);
  assertThat(shutdownSync.canShutdown(baseDN1)).isTrue();
}

opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/PendingChangesTest.java:90-101, :231-242

issue (blocking): No case pins that the announcement of a published message stands — "withdraw unconditionally" is green 33/33.

Measured: with withdraw(offlineCSN) run on both arms of if (domain.publish(msg)), PendingChangesTest + DSRSShutdownSyncTest pass 33/33. theReplicaOfflineMsgIsAnnouncedBeforeItIsPublished forwards from inside the publish and then asserts only canShutdown == true; the refusal cases end on true as well. The only assertFalse(canShutdown) is inside refuseWhilePublishing (:252), so "announce deleted" dies once and "withdraw always" never. That mutant undoes #919 entirely and passes CI.

/** The announcement of a message the broker took stands until a peer forwards it. */
@Test
public void theAnnouncementOfAPublishedMessageStandsUntilItIsForwarded() throws Exception
{
  final DSRSShutdownSync shutdownSync = new DSRSShutdownSync();
  final PendingChanges pendingChanges = newPendingChanges(domainWhichPublishes(true), shutdownSync);

  pendingChanges.putReplicaOfflineMsg();

  assertFalse(shutdownSync.canShutdown(baseDN),
      "the message went out and nobody has forwarded it yet, so the shutdown must wait for it");
}

And in forwardWhilePublishing, before the forward — then case 1 pins its own name:

if (msg instanceof ReplicaOfflineMsg)
{
  assertFalse(shutdownSync.canShutdown(baseDN), "the message must be announced before it is published");
  shutdownSync.replicaOfflineMsgForwarded(baseDN, msg.getCSN(), RS_ID);
}

Non-blocking

opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:806-820

suggestion (non-blocking): The production announcer is exercised by no test — swapping replicaOfflineMsgSent and replicaOfflineMsgNotSent here survives the suite.

Every test builds its own ReplicaOfflineAnnouncer (PendingChangesTest:264-275) or announces by hand. A package-visible ShutdownSyncAnnouncer(DSRSShutdownSync, DN) in place of the anonymous class, plus one case — announcecanShutdown false, withdraw → true — pins the edge.

opendj-server-legacy/src/test/java/org/opends/server/replication/service/DSRSShutdownSyncTest.java:474-513

suggestion (non-blocking): theWaitEndsWhenTheMessageIsWithdrawn pins the wake-up only by elapsed < LONG_GRACE_PERIOD (60 s); a withdrawal that notifies without emptying the slot passes it.

assertThat(shutdownSync.canShutdown(baseDN1)).as("the withdrawn message holds nothing back").isTrue();

opendj-server-legacy/src/test/java/org/opends/server/replication/service/DSRSShutdownSyncTest.java:106-122opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:128

todo (non-blocking): Both texts describe an interleaving that cannot happen: announce() and withdraw() run back to back under pushCommittedChanges()'s monitor (PendingChanges.java:175), one announcer per domain, one domain per baseDN per JVM — no "other thread of the domain" announces in between. The reachable second announcement is the one in the blocking issue above; theWithdrawalOfAnEarlierMessageLeavesANewerOneAlone pins the reverse order. Drop "another thread" from the javadoc and "a newer one made in the meantime" from :128, and say what the case does pin: a stale withdrawal is ignored.

opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:49

todo (non-blocking): "counted from the moment the message was sent" — with this PR the clock starts at the announcement, before the publish; :393 already says "announced".

PR description

suggestion (non-blocking): "what is announced is what really went out, and nothing else" is stronger than what the broker can report: Session.publish() returns silently for a pre-V8 peer (getBytes() == null, #1014) and after closeInitiated, and publish() reports true. Pre-existing (#976), only noting — "what the broker reports as written" is the claim that holds.

@vharseko
vharseko force-pushed the issues/950-announce-replica-offline-before-publish branch from 4aeb3b3 to fba06c0 Compare September 14, 2026 14:17
@vharseko

Copy link
Copy Markdown
Member Author

Round 1 addressed in fba06c0ee9; rebased onto master (cebef54070, where #958, #964 and #974 have
landed - none of them touches this code, the merge was clean). Still one commit,
git log origin/master..HEAD shows only it.

Blocking 1 - a withdrawal emptied a slot announce() had re-used. Confirmed as described:
the second disableService() announces CSN2 over CSN1, the broker refuses it on connectionError
or connectRequiresRecovery, and withdraw(CSN2) removed the slot CSN1 was still waiting in - a
regression against BASE, where a refused CSN2 was never announced. Fixed in the shape suggested:
PendingOfflineMsg keeps the announcement it displaced (compute() in replicaOfflineMsgSent),
and replicaOfflineMsgNotSent puts it back with the three-argument replace(), or removes the
entry when there was none. theWithdrawalOfALaterMessageGivesTheEarlierOneItsWaitBack is in, as
proposed, and was watched failing first: "the earlier message went out and nobody has forwarded
it yet - expected false but was true"
.

One residue is named in the javadoc rather than handled: while CSN2 stands in CSN1's place,
whatever is reported about CSN1 - a forward, a peer going away - is not seen by it, and after the
restore the shutdown waits out what is left of CSN1's own grace period. The window is the one
refused publish (immediate on connectionError / connectRequiresRecovery; up to the reconnect
when the session is null), and the cost is bounded by a grace period which is already running.
Walking the displaced chain from the forward guard would close it, but I would rather not add
that for a window this narrow unless you see it differently.

Blocking 2 - nothing pinned that the announcement of a published message stands. Confirmed
by running the mutant: withdraw on both arms of if (domain.publish(msg)) was green 33/33.
theAnnouncementOfAPublishedMessageStandsUntilItIsForwarded is in, and forwardWhilePublishing
asserts from inside the publish that the announcement is already there. Re-run:

  • withdraw on both arms - dies on theAnnouncementOfAPublishedMessageStandsUntilItIsForwarded;
  • announcement moved back behind domain.publish() - theReplicaOfflineMsgIsAnnouncedBeforeItIsPublished
    now dies on its own name, "the message must be announced before it is published", as does
    theAnnouncementOfAReplicaOfflineMsgTheBrokerRefusedIsWithdrawn.

Production announcer untested. ShutdownSyncAnnouncer(DSRSShutdownSync, DN) is a
package-private class now, and PendingChangesTest.newPendingChanges() builds its pending
changes with it rather than with an announcer of its own - so all nine cases go through the
production code, and the swap of replicaOfflineMsgSent / replicaOfflineMsgNotSent dies on
three of them. That seemed better than one dedicated case next to a duplicate.

theWaitEndsWhenTheMessageIsWithdrawn. canShutdown().isTrue() added. One note on the
rationale: a withdrawal which notified without emptying the slot would have failed this case
already, on elapsed < LONG_GRACE_PERIOD - awaitReplicaOfflineMsgsForwarded() re-reads the
remaining grace period after each wake-up and goes back to waiting - but only after the 60 s;
the added assertion makes it immediate and says why.

"Another thread" / "a newer one made in the meantime". Both reworded. The test's javadoc now
says what it pins - a stale withdrawal, of a message the replica has since announced again, is
ignored - and the replicaOfflineMsgNotSent javadoc describes the displaced announcement
instead.

:49 - "announced".

"What really went out, and nothing else". The sentence was in the ReplicaOfflineAnnouncer
javadoc and the commit message rather than the description; all three now say "what the broker
reports as written", and the description names the two silent returns of Session.publish()
as #976's contract, unchanged here.

Tests, on the rebased head, class per JVM: PendingChangesTest 9, DSRSShutdownSyncTest 26,
ReplicationServerShutdownSyncTest 13 - 48, no failures; -Pprecommit reactor and javadoc doclint
green. The description above is updated to match.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Both round-1 Majors are closed, and closed the way the review hoped for.

  • The displaced announcement travels with the one that displaced it: compute() chains it in replicaOfflineMsgSent, and replicaOfflineMsgNotSent puts it back with an identity replace(k, pending, displaced), so a withdrawal cannot clobber a concurrent announce either.
  • The mutant kills claimed in the reply hold by reading: "withdraw on both arms" dies at PendingChangesTest:116, "announce behind the publish" dies on forwardWhilePublishing's in-Answer assertFalse — the race is reproduced inside the mocked publish, not waited for.
  • ShutdownSyncAnnouncer as a package-private class instead of the anonymous announcer: production and the tests build the domain's PendingChanges through one seam.
  • The trade-off is disclosed where it lives: the replicaOfflineMsgNotSent javadoc names the window and its bound, and the description repeats it.

issue (non-blocking): the restore case pins the CSN only; "gives the earlier one its wait back" is pinned by nothing.

opendj-server-legacy/src/test/java/org/opends/server/replication/service/DSRSShutdownSyncTest.java:157-173, opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:161

theWithdrawalOfALaterMessageGivesTheEarlierOneItsWaitBack asserts isFalse after the withdrawal and isTrue after one forward; neither looks at what came back — not the restored entry's sentTime, not its awaitedForwarders. Rebuilding the displaced entry instead of restoring it — msgs.replace(serverId, pending, new PendingOfflineMsg(pending.displaced.csn, System.nanoTime(), null)) — is green 9/9 + 26/26 (measured at head). Under it the wait restarts at the withdrawal and the first peer's forward ends a wait queued for several: both description sentences false, nothing red. The production line is right; no test would notice if it stopped being.

@Test
public void theRestoredAnnouncementIsStillOwedTheForwardsItWasQueuedFor() throws Exception
{
  final DSRSShutdownSync shutdownSync = new DSRSShutdownSync(LONG_GRACE_PERIOD);
  final CSN sentByTheShutdown = newCSN(SERVER_ID, 1);
  final CSN refusedByTheBroker = newCSN(SERVER_ID, 2);

  shutdownSync.replicaOfflineMsgSent(baseDN1, sentByTheShutdown);
  shutdownSync.replicaOfflineMsgDispatched(baseDN1, sentByTheShutdown, asList(RS_ID, OTHER_RS_ID));
  shutdownSync.replicaOfflineMsgSent(baseDN1, refusedByTheBroker);
  shutdownSync.replicaOfflineMsgNotSent(baseDN1, refusedByTheBroker);

  shutdownSync.replicaOfflineMsgForwarded(baseDN1, sentByTheShutdown, RS_ID);
  assertThat(shutdownSync.canShutdown(baseDN1))
      .as("the restored message is still owed the other peer's forward")
      .isFalse();
  shutdownSync.replicaOfflineMsgForwarded(baseDN1, sentByTheShutdown, OTHER_RS_ID);
  assertThat(shutdownSync.canShutdown(baseDN1)).isTrue();
}

This alone kills the mutant. Optionally pin "what is left of its own grace period" too: GRACE_PERIOD, Sent(1), sleep most of it, Sent(2), NotSent(2), canShutdown true within the remainder rather than a full period.


issue (non-blocking): theWithdrawalOfAnEarlierMessageLeavesANewerOneAlone no longer pins the withdrawal's CSN guard.

opendj-server-legacy/src/test/java/org/opends/server/replication/service/DSRSShutdownSyncTest.java:135-148, opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:157

With && pending.csn.equals(offlineCSN) deleted, the stale NotSent(1) finds the newer entry, restores CSN 1 behind it, and canShutdown is still false — green 9/9 + 26/26 (measured). Round 1's remove killed this mutant; the restore silently un-killed it. No production road produces a stale withdrawal, so hygiene: the case says it pins the guard and is green without it. The tell is a forward of the withdrawn CSN — ignored at head, consumed under the mutant:

shutdownSync.replicaOfflineMsgNotSent(baseDN1, refusedByTheBroker);
assertThat(shutdownSync.canShutdown(baseDN1)).isFalse();

shutdownSync.replicaOfflineMsgForwarded(baseDN1, refusedByTheBroker, RS_ID);
assertThat(shutdownSync.canShutdown(baseDN1))
    .as("the stale withdrawal was ignored, not turned into a restore")
    .isFalse();

A forward of the newer CSN does not do it: isOlderThanOrEqualTo accepts it for either entry.


suggestion (non-blocking): the "not seen" window opens at the announce, not at the publish, and that end has a one-condition fix.

opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:226-236, :159-162

replicaOfflineMsgForwarded is getforwardedBy() (mutates awaitedForwarders) → identity remove(k, pending). A compute() from the next announce between the get and the remove makes the CAS fail: the entry — fully forwarded, awaited set empty — stays behind the new one as displaced. If that new message is then refused, the restore puts it back, and giveUpOn never releases an empty set: only the grace expiry ends the wait. Bounded and of the disclosed class, so not a bug — and it needs no chain walk:

// replicaOfflineMsgNotSent
if (pending.displaced != null && !pending.displaced.isFullyForwarded())
{
  msgs.replace(serverId, pending, pending.displaced);
}
else
{
  msgs.remove(serverId, pending);
}

// PendingOfflineMsg
/** Whether every replication server the message was queued for has forwarded it. */
private boolean isFullyForwarded()
{
  final Set<Integer> awaited = awaitedForwarders;
  return awaited != null && awaited.isEmpty();
}

suggestion (non-blocking): the displaced chain is never pruned where the collocated RS never reports on the replica.

opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:122-125, :387-399

compute() chains the previous entry on every announce; the only drops are the top-entry removes (:205, :236, :265) and the restore, one step back. Where nothing dispatches or forwards on this replica — a DS-only JVM, or a replica on a remote RS, since ReplicationServerDomain dispatches only under sourceHandler.isDataServer() — every restartService(), total update or config toggle adds one ~50 B node for the life of the JVM; at the base the slot held one entry. Retention only, negligible, and an expired entry restored yields nothing anyway:

.compute(offlineCSN.getServerId(), (serverId, displaced) ->
    new PendingOfflineMsg(offlineCSN, announcedAt,
        displaced != null && NANOSECONDS.toMillis(announcedAt - displaced.sentTime) < gracePeriod
            ? displaced : null));

suggestion (non-blocking): the disclosed residue has no case.

opendj-server-legacy/src/test/java/org/opends/server/replication/service/DSRSShutdownSyncTest.java

None of the 26 cases forwards while a refused announcement stands in front of a sent one; aStaleForwardDoesNotConsumeTheGracePeriodOfANewerMessage never restores. The case documents the trade-off, so a change in its shape turns red:

shutdownSync.replicaOfflineMsgSent(baseDN1, sentByTheShutdown);
shutdownSync.replicaOfflineMsgDispatched(baseDN1, sentByTheShutdown, asList(RS_ID));
shutdownSync.replicaOfflineMsgSent(baseDN1, refusedByTheBroker);
shutdownSync.replicaOfflineMsgForwarded(baseDN1, sentByTheShutdown, RS_ID); // ignored: CSN 2 stands
shutdownSync.replicaOfflineMsgNotSent(baseDN1, refusedByTheBroker);

assertThat(shutdownSync.canShutdown(baseDN1))
    .as("a forward reported while the refused announcement stood is not seen")
    .isFalse();

nitpick (non-blocking): "the one publish the broker refuses" is the broker's retry loop.

opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:139-143

broker.publish(msg, retryOnFailure = true) loops — no session, connectPhaseLock, tryAcquire 500 ms — until the reconnect, the refusal or the shutdown. Your reply says "up to the reconnect"; the javadoc could say it too.


nitpick (non-blocking): a Dispatched dropped behind the refused entry gives the restored one the opposite consequence to the ones the javadoc names.

opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:201-207, :453-460

replicaOfflineMsgDispatched reads the top only, so a Dispatched(CSN 1, ids) arriving while the refused CSN 2 stands is dropped; the restored CSN 1 keeps awaitedForwarders == null and forwardedBy() ends the wait on the first peer's forward — an early exit, where "a forward, the loss of a peer" both lengthen it. Practically unreachable (the RS reader thread records it right after the socket read; the DS has to complete disable → enable → disable first), so a word in the javadoc, unless you want the one-node descent in Dispatched as well.


nitpick (non-blocking): the description's road for the round-1 withdrawal item names a sequence no main code runs.

PR description, "A withdrawal must not take an earlier announcement with it"

"a shutdown whose message went out, followed within the grace period by an enableService() … and then by another disableService()" — nothing calls enableService() after LDAPReplicationDomain.shutdown(). The roads are the config toggle, the total-update pair, restartService() and the locked config change; the mechanism is the same on each. If "shutdown" means the collocated RS's, say so.

…published, not after it may have been forwarded

The announcement the shutdown of a collocated replication server waits on was recorded
after PendingChanges.putReplicaOfflineMsg() had already put the message on the wire. A
forward which won that race found nothing to clear, and the announcement which followed
it was one nothing would ever remove: ReplicationServer.shutdown() then spent the whole
REPLICA_OFFLINE_GRACE_PERIOD waiting for the forward of a message the topology already
had.

The announcement now sits where the message is published - the ReplicaOfflineMsg branch
of pushCommittedChanges() - so it is in place before session.publish() is reached and the
forward cannot precede it. It goes through ShutdownSyncAnnouncer, the announcer of one
domain and one DSRSShutdownSync, which the domain hands its PendingChanges. Announcing at
the publish site also makes it follow the publication rather than the queueing, which
leaves the guard OpenIdentityPlatform#918 put around the announcement nothing to do: a message a change in
flight holds back is not published, and is therefore not announced either. The trace
which reports such a message stays.

The broker may still refuse the message once it is announced - no usable session, a
recovery pending, or stopped in between - which OpenIdentityPlatform#949 made domain.publish() report. Such
an announcement is one nobody will ever forward, so it is withdrawn through the new
DSRSShutdownSync.replicaOfflineMsgNotSent(), which takes back only the entry carrying
that CSN and wakes the shutdown up as a forward does: what stays announced is what the
broker reports as written.

A withdrawal gives back what the announcement displaced. A replica announces itself
offline on every disableService(), and each announcement replaces the entry of the
replica, so the refused message of a disable which follows a failed re-enable within the
grace period had taken the place of the message the earlier disable did send - and
withdrawing it emptied the slot that message was still waiting in. PendingOfflineMsg now
keeps the announcement it displaced, and the withdrawal puts it back, with its own clock
and the peers it was queued for. It keeps it only while its grace period runs, or every
announcement of a replica whose message nobody in this process forwards would be chained
for the life of the process; and it gives back only an announcement which is still owed a
forward, since the forward which released the displaced one may have been reported while
the new announcement was being made, and its remove() then found the new entry in its
place.

New PendingChangesTest cases, all through the production announcer: the forward reported
from within publish() finds the announcement in place and clears it, the announcement of
a message the broker took stands until a peer forwards it, the announcement of a message
the broker refused is withdrawn, and a message held back by a change in flight is never
announced - neither while it waits, nor when the change which held it back completes and
the message is given up on. DSRSShutdownSyncTest covers the withdrawal: it ends the wait
and leaves nothing behind, it wakes a waiting shutdown up, it leaves a newer announcement
of the same replica alone - and does not restore the stale one -, it gives an earlier one
its wait back with the peers it was queued for and what is left of its own grace period,
and a forward reported while the refused announcement stood in front is not seen.
@vharseko
vharseko force-pushed the issues/950-announce-replica-offline-before-publish branch from fba06c0 to 43f3d15 Compare September 15, 2026 14:23
@vharseko

Copy link
Copy Markdown
Member Author

Round 2 addressed in 43f3d15faf; rebased onto master (fef4292a5f, where #977, #1033 and #1047 have
landed - #977 rewrites other parts of LDAPReplicationDomain, the merge was clean). Still one
commit, git log origin/master..HEAD shows only it.

The restore case pinned the CSN only. Confirmed by running the mutant: with the displaced entry
rebuilt - new PendingOfflineMsg(displaced.csn, System.nanoTime(), null) - the two cases below are
the only ones which fail, 27 of 29 stay green, so nothing else in the class was looking.
theRestoredAnnouncementIsStillOwedTheForwardsItWasQueuedFor is in as proposed, and the
grace-period half too, as
theRestoredAnnouncementKeepsWhatIsLeftOfItsOwnGracePeriod: GRACE_PERIOD, Sent(1), 300 ms,
Sent(2), NotSent(2), 250 ms more, canShutdown true - a whole new period would still be running.
The second one is there because the first alone does not pin the clock: a mutant which copies the
peers over and resets only sentTime survives it, and dies on the second - measured, both ways.

The stale-withdrawal case no longer pinned the guard. Confirmed: with csn.equals dropped,
NotSent(1) restored CSN 1 behind the newer entry and the case stayed green. The forward of the
withdrawn CSN is in, with the assertion text you proposed; the mutant dies on it.

The residue's other end. Taken: replicaOfflineMsgNotSent gives the displaced announcement its
place back only while !isFullyForwarded() - awaitedForwarders known and empty - and removes
the entry otherwise, with the race named at the condition. No case for it, and I would rather say
so than pretend: the displaced entry's set can only empty through the forward guard, which reads
the top entry, so a test would have to land the announce between its get and its remove.
"Restore unconditionally" therefore survives, by construction.

The chain is never pruned. Taken, in the shape you gave: compute() keeps the displaced entry
only while gracePeriodLeft(displaced, announcedAt) > 0, and remainingGracePeriod reads the
same helper. The chain is reachable only through the top entry, so the first announcement made
more than a grace period after the previous one drops the whole of it. Worth adding to your list
of producers: restartSession() of #974 announces again every 1-10 s for as long as a replay keeps
failing, so on a replica whose message nobody in this process forwards the chain was not a
one-off per config change but a steady drip. Retention only, so "keep unconditionally" survives
the suite, and the description says so.

The disclosed residue has no case. aForwardReportedWhileARefusedAnnouncementStoodIsNotSeen
is in as proposed. It stays false with the isFullyForwarded() check above, as it should: a
forward of CSN 1 while CSN 2 stands in front never reaches CSN 1's set.

"The one publish the broker refuses". The javadoc now says where the window ends: at once on a
connection error or a pending recovery, the broker's retry loop up to the reconnect when it has no
session.

A Dispatched dropped behind the refused entry. The javadoc names it, with its opposite
consequence: the restored announcement keeps no recipients, and the first forward ends its wait
as for a message no peer was recorded for. The one-node descent is left out for a window the RS
reader thread closes right after the socket read.

The description's road. Rewritten: the roads are the fractional/assured config change,
restartService(), the disable()/enable() pair of a total update and restartSession();
nothing calls enableService() after shutdown().

Tests, on the rebased head, class per JVM: PendingChangesTest 9, DSRSShutdownSyncTest 29,
ReplicationServerShutdownSyncTest 13 - 51, no failures; -Pprecommit reactor and javadoc
doclint green. Mutants re-run on this head, each dying where its name says:

  • rebuild the displaced entry - theRestoredAnnouncementIsStillOwedTheForwardsItWasQueuedFor and
    theRestoredAnnouncementKeepsWhatIsLeftOfItsOwnGracePeriod;
  • reset only its clock - theRestoredAnnouncementKeepsWhatIsLeftOfItsOwnGracePeriod;
  • drop the csn.equals guard - theWithdrawalOfAnEarlierMessageLeavesANewerOneAlone, on "the
    stale withdrawal was ignored, not turned into a restore"
    ;
  • the three of the earlier rounds - announce behind the publish, withdraw on both arms, the swap in
    ShutdownSyncAnnouncer - die on the same cases as before.

The description above is updated to match.

@vharseko vharseko added the java Changes to Java sources label Sep 15, 2026
@vharseko
vharseko merged commit 600df92 into OpenIdentityPlatform:master Sep 15, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug concurrency Thread-safety / race-condition bugs java Changes to Java sources replication tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A ReplicaOfflineMsg forwarded before it is recorded leaves a pending announcement nothing will clear, and the shutdown waits out its grace period

2 participants